
package org.spiderflow.core.expression.reflection;

import org.spiderflow.core.exception.SpiderException;

import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @author zsh
 */
public class JavaReflection {
    private final Map<Class<?>, Map<String, Field>> fieldCache = new ConcurrentHashMap<>();
    private final Map<Class<?>, Map<MethodSignature, Method>> methodCache = new ConcurrentHashMap<>();
    private final Map<Class<?>, Map<String, List<Method>>> extensionMethodCache = new ConcurrentHashMap<>();
    private static final Map<Class<?>, Class<?>> WRAPPER_MAP;
    private static final JavaReflection INSTANCE = new JavaReflection();

    private JavaReflection() {
    }

    public static JavaReflection getInstance() {
        return INSTANCE;
    }

    static {
        WRAPPER_MAP = new HashMap<>(17);
        WRAPPER_MAP.put(Boolean.TYPE, Boolean.class);
        WRAPPER_MAP.put(Byte.TYPE, Byte.class);
        WRAPPER_MAP.put(Character.TYPE, Character.class);
        WRAPPER_MAP.put(Short.TYPE, Short.class);
        WRAPPER_MAP.put(Integer.TYPE, Integer.class);
        WRAPPER_MAP.put(Long.TYPE, Long.class);
        WRAPPER_MAP.put(Double.TYPE, Double.class);
        WRAPPER_MAP.put(Float.TYPE, Float.class);
        WRAPPER_MAP.put(int[].class, Integer[].class);
        WRAPPER_MAP.put(long[].class, Long[].class);
        WRAPPER_MAP.put(short[].class, Short[].class);
        WRAPPER_MAP.put(double[].class, Double[].class);
        WRAPPER_MAP.put(float[].class, Float[].class);
        WRAPPER_MAP.put(byte[].class, Byte[].class);
        WRAPPER_MAP.put(char[].class, Character[].class);
        WRAPPER_MAP.put(boolean[].class, Boolean[].class);
        WRAPPER_MAP.put(Void.TYPE, Void.class);
    }

    @SuppressWarnings("rawtypes")
    public Field getField(Object obj, String name) {
        Class cls = obj instanceof Class ? (Class) obj : obj.getClass();
        Map<String, Field> fields = fieldCache.computeIfAbsent(cls, k -> new ConcurrentHashMap<>());
        Field field = fields.get(name);
        if (field != null) {
            return field;
        }
        field = getDeclaredField(name, cls, fields);
        if (field != null) {
            return field;
        }
        Class parentClass = cls.getSuperclass();
        while (parentClass != Object.class && parentClass != null) {
            field = getDeclaredField(name, parentClass, fields);
            parentClass = parentClass.getSuperclass();
        }
        return field;
    }

    @SuppressWarnings("java:S3011")
    private Field getDeclaredField(String name, Class<?> cls, Map<String, Field> fields) {
        try {
            Field field = cls.getDeclaredField(name);
            field.setAccessible(true);
            fields.put(name, field);
            return field;
        } catch (NoSuchFieldException e) {
            return null;
        }
    }

    public Object getFieldValue(Object obj, Object field) {
        Field javaField = (Field) field;
        try {
            return javaField.get(obj);
        } catch (RuntimeException | IllegalAccessException e) {
            throw new SpiderException("Couldn't get value of field '" + javaField.getName() + "' from object of type '" + obj.getClass().getSimpleName() + "'");
        }
    }

    public void registerExtensionClass(Class<?> target, Class<?> clazz) {
        Method[] methods = clazz.getDeclaredMethods();
        Map<String, List<Method>> cachedMethodMap = extensionMethodCache.computeIfAbsent(target, k -> new HashMap<>());
        for (Method method : methods) {
            if (Modifier.isStatic(method.getModifiers()) && method.getParameterCount() > 0) {
                List<Method> cachedList = cachedMethodMap.computeIfAbsent(method.getName(), k -> new ArrayList<>());
                cachedList.add(method);
            }
        }
    }

    public Method getExtensionMethod(Object obj, String name, Object... arguments) {
        Class<?> cls = obj instanceof Class ? (Class<?>) obj : obj.getClass();
        if (cls.isArray()) {
            cls = Object[].class;
        }
        return getExtensionMethod(cls, name, arguments);
    }

    private Method getExtensionMethod(Class<?> cls, String name, Object... arguments) {
        if (cls == null) {
            cls = Object.class;
        }
        Map<String, List<Method>> methodMap = extensionMethodCache.get(cls);
        if (methodMap != null) {
            List<Method> methodList = methodMap.get(name);
            if (methodList != null) {
                Class<?>[] parameterTypes = new Class[arguments.length + 1];
                parameterTypes[0] = cls;
                for (int i = 0; i < arguments.length; i++) {
                    parameterTypes[i + 1] = arguments[i] == null ? null : arguments[i].getClass();
                }
                return findMethod(methodList, parameterTypes);
            }
        }
        if (cls != Object.class) {
            return findExtensionMethod(cls, name, arguments);
        }
        return null;
    }

    private Method findExtensionMethod(Class<?> cls, String name, Object[] arguments) {
        Class<?>[] interfaces = cls.getInterfaces();
        for (Class<?> clazz : interfaces) {
            Method method = getExtensionMethod(clazz, name, arguments);
            if (method != null) {
                return method;
            }
        }
        return getExtensionMethod(cls.getSuperclass(), name, arguments);
    }

    public Method getMethod(Object obj, String name, Object... arguments) {
        Class<?> cls = obj instanceof Class ? (Class<?>) obj : obj.getClass();
        Map<MethodSignature, Method> methods = methodCache.computeIfAbsent(cls, k -> new ConcurrentHashMap<>());
        Class<?>[] parameterTypes = new Class[arguments.length];
        for (int i = 0; i < arguments.length; i++) {
            parameterTypes[i] = arguments[i] == null ? null : arguments[i].getClass();
        }
        MethodSignature signature = new MethodSignature(name, parameterTypes);
        return methods.computeIfAbsent(signature, k -> findMethod(name, cls, parameterTypes));
    }

    private static Method findMethod(String name, Class<?> cls, Class<?>[] parameterTypes) {
        Method method;
        if (name == null) {
            method = findApply(cls);
        } else {
            method = findMethod(cls, name, parameterTypes);
            if (method == null) {
                method = findMethod(cls, name, new Class<?>[]{Object[].class});
            }
        }
        if (setMethodAccessible(method)) {
            return method;
        }
        Class<?> parentClass = cls.getSuperclass();
        while (parentClass != Object.class && parentClass != null) {
            if (name == null) {
                method = findApply(parentClass);
            } else {
                method = findMethod(parentClass, name, parameterTypes);
            }
            parentClass = parentClass.getSuperclass();
        }
        setMethodAccessible(method);
        return method;
    }

    @SuppressWarnings("java:S3011")
    private static boolean setMethodAccessible(Method method) {
        if (method == null) {
            return false;
        }
        method.setAccessible(true);
        return true;
    }

    /**
     * Returns the <code>apply()</code> method of a functional interface.
     **/
    private static Method findApply(Class<?> cls) {
        return Arrays.stream(cls.getDeclaredMethods())
                     .filter(method -> Objects.equals(method.getName(), "apply"))
                     .findAny().orElse(null);
    }

    private static Method findMethod(List<Method> methods, Class<?>[] parameterTypes) {
        Method foundMethod = null;
        int foundScore = 0;
        for (Method method : methods) {
            // Check if the types match.
            Class<?>[] otherTypes = method.getParameterTypes();
            if (parameterTypes.length != otherTypes.length) {
                continue;
            }
            boolean match = true;
            int score = 0;
            for (int i = 0, n = parameterTypes.length; i < n; i++) {
                Class<?> type = parameterTypes[i];
                Class<?> otherType = otherTypes[i];
                int scoreAdd = getScoreAdd(type, otherType);
                if (scoreAdd < 0) {
                    match = false;
                    break;
                }
                score += scoreAdd;
            }
            if (match) {
                if (foundMethod == null) {
                    foundMethod = method;
                    foundScore = score;
                } else if (score < foundScore) {
                    foundScore = score;
                    foundMethod = method;
                }
            }
        }
        return foundMethod;
    }

    private static int getScoreAdd(Class<?> type, Class<?> otherType) {
        int scoreAdd = 0;
        if (!otherType.isAssignableFrom(type)) {
            scoreAdd++;
            if (!isPrimitiveAssignableFrom(type, otherType)) {
                scoreAdd++;
                if (!isCoercible(type, otherType)) {
                    return -1;
                } else {
                    scoreAdd++;
                }
            }
        }
        return scoreAdd;
    }

    /**
     * Returns the method best matching the given signature, including type coercion, or null.
     **/
    private static Method findMethod(Class<?> cls, String name, Class<?>[] parameterTypes) {
        Method[] methods = cls.getDeclaredMethods();
        List<Method> methodList = new ArrayList<>();
        for (Method method : methods) {
            // if neither name or parameter list size match, bail on this method
            if (!Objects.equals(method.getName(), name) || method.getParameterTypes().length != parameterTypes.length) {
                continue;
            }
            methodList.add(method);
        }
        return findMethod(methodList, parameterTypes);
    }

    /**
     * Returns whether the from type can be assigned to the to type, assuming either type is a (boxed) primitive type. We can
     * relax the type constraint a little, as we'll invoke a method via reflection. That means the from type will always be boxed,
     * as the {@link Method#invoke(Object, Object...)} method takes objects.
     **/
    private static boolean isPrimitiveAssignableFrom(Class<?> from, Class<?> to) {
        return primitiveToWrapper(from) == primitiveToWrapper(to);
    }

    public static String[] getStringTypes(Object[] objects) {
        String[] parameterTypes = new String[objects == null ? 0 : objects.length];
        if (objects != null) {
            for (int i = 0, len = objects.length; i < len; i++) {
                Object value = objects[i];
                parameterTypes[i] = value == null ? "null" : value.getClass().getSimpleName();
            }
        }
        return parameterTypes;
    }

    public static Class<?> primitiveToWrapper(Class<?> cls) {
        if (cls == null) {
            return null;
        }
        Class<?> checkClass = cls.isArray() ? cls.getComponentType() : cls;
        if (checkClass.isPrimitive()) {
            return WRAPPER_MAP.get(cls);
        } else {
            return cls;
        }
    }

    /**
     * Returns whether the from type can be coerced to the to type. The coercion rules follow those of Java. See JLS 5.1.2
     * https://docs.oracle.com/javase/specs/jls/se7/html/jls-5.html
     **/
    private static boolean isCoercible(Class<?> from, Class<?> to) {
        Class<?> fromWrapper = primitiveToWrapper(from);
        Class<?> toWrapper = primitiveToWrapper(to);
        if (fromWrapper == Integer.class) {
            return toWrapper == Float.class || toWrapper == Double.class || toWrapper == Long.class;
        } else if (fromWrapper == Float.class) {
            return toWrapper == Double.class;
        } else if (fromWrapper == Double.class) {
            return false;
        } else if (fromWrapper == Character.class || fromWrapper == Short.class) {
            return toWrapper == Integer.class || toWrapper == Float.class || toWrapper == Double.class
                    || toWrapper == Long.class;
        } else if (fromWrapper == Byte.class) {
            return toWrapper == Integer.class || toWrapper == Float.class || toWrapper == Double.class
                    || toWrapper == Long.class || toWrapper == Short.class;
        } else if (fromWrapper == Long.class) {
            return toWrapper == Float.class || toWrapper == Double.class;
        } else if (fromWrapper == Integer[].class) {
            return toWrapper == Object[].class || toWrapper == Float[].class || toWrapper == Double[].class
                    || toWrapper == Long[].class;
        }
        return false;
    }

    public Object callMethod(Object obj, Object method, Object... arguments) {
        Method javaMethod = (Method) method;
        try {
            return javaMethod.invoke(obj, arguments);
        } catch (RuntimeException | IllegalAccessException | InvocationTargetException t) {
            throw new SpiderException("Couldn't call method '" + javaMethod.getName() + "' with arguments '" + Arrays.toString(arguments)
                    + "' on object of type '" + obj.getClass().getSimpleName() + "'.", t);
        }
    }
}
